3.1. Tools
What makes a function a good agent tool?
A good tool has one purpose, a narrow typed signature, an action-oriented docstring, a stable JSON-serializable result, validation at every untrusted boundary, and a clear error value. ADK derives the model-visible schema from the Python signature and docstring.
def get_incident(incident_id: str) -> dict[str, Any]:
"""Get the full details of one incident by its id."""
normalized = normalize_incident_id(incident_id)
if normalized is None:
return {"error": f"Invalid incident id {incident_id!r}; expected an id like INC-002."}
incident = data.get_incident(normalized)
if incident is None:
return {"error": f"No incident found with id {normalized!r}."}
return {"incident": incident.model_dump(mode="json")}
The model controls incident_id, so normalization happens before a database query. Unknown records return a useful tool result instead of an exception or hallucinated fallback.
Which read tools does the agent expose?
| Tool | Trusted result |
|---|---|
list_incidents |
Filtered incidents with id, service, severity, state, time, and summary. |
get_incident |
One validated full Incident, including runbook slug. |
get_service_status |
One validated service plus unresolved incidents. |
search_service_logs |
At most 100 lines from an allowlisted service log file. |
get_runbook |
One validated Markdown runbook by slug. |
search_runbooks |
Deterministically ranked runbook content. |
State-changing restart_service and resolve_incident are separate FunctionTool instances with confirmation and audit requirements. They are not exported by the MCP server.
How are database rows trusted?
SQLite is external input even when bundled with the repository. The data layer validates rows with Pydantic models configured to reject extra fields:
class Incident(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str = Field(pattern=r"^INC-\d+$")
service: str = Field(pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
title: str = Field(min_length=1)
severity: Severity
status: IncidentStatus
runbook: str
opened_at: str
resolved_at: str | None
summary: str
Schema constraints protect storage; domain models protect the Python boundary; tool validation protects model-controlled arguments. Each catches a different class of defect.
Why does the seed get copied?
The first stateful access atomically copies agents/data/incidents.db into .state/incidents.db. Reads and mock actions then use the writable copy. Parallel startup cannot overwrite an initialized destination, and a failed copy is not published as valid state.
destination = settings.state_dir / "incidents.db"
source = settings.data_dir / "incidents.db"
# Copy to a temporary file, fsync it, then publish with an exclusive hard link.
This keeps Git clean and lets every learner reset to the exact seed.
How are tool failures reported?
Expected validation/lookup failures return {"error": ...}. Unexpected exceptions cross on_tool_error_callback, which logs the exception server-side and returns a stable, non-sensitive message to the model. Never return raw SQL, filesystem paths, tracebacks, or credentials in a tool result.
Why not expose one generic database tool?
query_database(sql: str) would give the model far more authority than the task needs and make policy/evaluation difficult. Small functions create a capability allowlist, generate better schemas, and produce meaningful audit/trajectory evidence.
What is the tool checkpoint?
Add one invalid id, slug, status, query limit, and path-traversal case before adding a new tool. Confirm the committed seed remains unchanged after the test.
How would you add a get_oncall_schedule read tool?
Exercise: go beyond the checkpoint by shipping a new read-only tool end to end.
- Goal: expose a
get_oncall_scheduletool that returns the current on-call owner, parsed and validated at the boundary like the existing read tools. - Files to touch: a small seed table under
agents/data/(asql/row set or a JSON file), a reader inagents/python/src/agent/data.py, the tool inagents/python/src/agent/tools.py(registered on the agent), and new cases inagents/python/tests/test_tools.pyandtests/test_data.py. - Gate that proves completion:
cd agents/python && uv run pytest tests/test_tools.py tests/test_data.pypasses with both a valid-lookup case and a rejected-invalid-input case, and the branch-coverage gate stays green.